Skip to content

Fix Cursor::{next,previous}_logical_word and skip over whitespace - #215

Merged
tomcur merged 4 commits into
linebender:mainfrom
tomcur:prev-logical
Jul 19, 2026
Merged

tomcur merged 4 commits into
linebender:mainfrom
tomcur:prev-logical

Conversation

@tomcur

@tomcur tomcur commented Dec 9, 2024 •

Copy link
Copy Markdown
Member

Currently, in foo b|ar where | indicates the cursor position, Cursor::previous_logical_word will return foo| bar. This doesn't match the behavior of Cursor::{next_logical_word, previous_visual_word} which place the cursor at the boundary of the current word. This happens because Cluster::previous_logical_word is called from the upstream cluster (the "b") and the cursor then lands between the "o" and the space.

This also renames "left", "right" -> "upstream", "downstream" (naming copied from Cursor::logical_clusters) to make it clear we're operating in logical order and not visual, and attaches the cursor to the word whose boundary it lands on.

The upstream/downstream fix on its own with no other changes, would regress how whitespace is handled, e.g., in foo |bar, where previous_logical_word would then end up at foo| bar instead of |foo bar. The cleanest way to handle this, I think, is to fold in a fix for #604 at the same time, skipping over whitespace. That's what this PR now proposes. This aligns these logical jumps to the visual {previous, next}_visual_word.

Comment thread parley/src/layout/cursor.rs Outdated
Comment on lines +262 to +264
let [left, right] = self.logical_clusters(layout);
if let Some(cluster) = left.or(right) {
let [upstream, downstream] = self.logical_clusters(layout);
if let Some(cluster) = downstream.or(upstream) {

@tomcur tomcur Dec 9, 2024 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting as we're checking the same clusters (in logical order) as for the method in the other direction. I believe the asymmetry comes from the word boundary clusters being the logically first cluster of a word and (e.g.) the whitespace logically following the word, e.g., in "foo bar" the boundary clusters of the first word are "f" and " ".

@DJMcNab DJMcNab left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your bracketed note is also captured in #604.

Otherwise seems reasonable.

Comment thread parley/src/layout/cursor.rs Outdated
return Self::from_byte_index(layout, usize::MAX, Affinity::Downstream);
}
return Self::from_cluster(layout, cluster, true);
let moving_right = !cluster.is_rtl();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moving_right in from_cluster calculates the Affinity from the cluster's rtl; to calculate it from the rtl originally is... interesting.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah... I've simplified this a bit by using the Self::from_byte_index directly with an explicit Affinity, rather than going through the apply-then-unapply directionality dance to get at an affinity. Both next_logical_word and previous_logical_word now affine downstream, which I think is correct, and I've added comments explaining the behavior (and at least, manually wiring these methods up in the editor to visually inspect their behavior without mutating text seems to behave as I'd expect (and on main only backdelete calls prev_logical_word, so it's also not clear whether the affinity really matters at all here)).

Comment on lines +62 to +66
let focus = if let Some(end) = cluster.next_logical_word() {
Cursor::from_cluster(layout, end.clone(), !cluster.is_rtl())
} else {
Cursor::from_byte_index(layout, usize::MAX, Affinity::Downstream)
};

@tomcur tomcur Jul 1, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the new whitespace skipping, double-click-to-select can't use Cursor::next_logical_word anymore. Note the anchor already used Cluster::previous_logical_word, as opposed to focus that was using Cursor::next_logical_word (note: Cluster vs. Cursor as the type!).

Cluster uses the Unicode word boundaries from analysis, whereas Cursor encodes "editor behavior." When selecting a word in an editor by double clicking, you do want to stop at whitespace. Hence, this should use Cluster.

@tomcur tomcur changed the title Fix Cursor::previous_logical_word Fix Cursor::{next_,previous_}_logical_word and skip over whitespace Jul 1, 2026
@tomcur tomcur changed the title Fix Cursor::{next_,previous_}_logical_word and skip over whitespace Fix Cursor::{next,previous}_logical_word and skip over whitespace Jul 1, 2026
@tomcur
tomcur requested a review from DJMcNab July 1, 2026 11:52
@tomcur

tomcur commented Jul 1, 2026 •

Copy link
Copy Markdown
Member Author

This PR has changed a bit so probably needs re-review. The fix I originally proposed regressed whitespace handling. This now also includes a fix for #604, as I believe that's the only clean way to get this to behave as expected for both cases, e.g., previous_logical_word navigates as foo b|ar -> foo |bar -> |foo bar.

@DJMcNab DJMcNab left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify my understanding, these methods now jump to the "logical start" of the next or previous logical word, right?

That seems as reasonable as anything, but we probably should document it?

tomcur added 4 commits July 19, 2026 11:13
Currently, in `foo b|ar` where `|` indicates the cursor position,
`Cursor::previous_logical_word` will return `foo| bar`. This doesn't
match the behavior of `Cursor::{next_logical_word,
previous_visual_word}` which place the cursor at the boundary of the
current word. This happens because `Cluster::previous_logical_word` is
called from the upstream cluster (the "b") and the cursor then lands
between the "o" and the space.

This also renames "left", "right" -> "upstream", "downstream" (naming
copied from `Cursor::logical_clusters`) to make it clear we're operating
in logical order and not visual, and affines the cursor towards the word
whose boundary it lands on.

(Note the behavior between `{previous, next}_logical_word` and
{previous, next}_visual_word` aren't quite the same yet: the `visual`
methods jump over whitespace, the `logical` ones don't.)
@tomcur
tomcur enabled auto-merge July 19, 2026 09:27
@tomcur
tomcur added this pull request to the merge queue Jul 19, 2026
Merged via the queue into linebender:main with commit ad977ff Jul 19, 2026
24 checks passed
@tomcur
tomcur deleted the prev-logical branch July 19, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants